Skip to content

Add currency conversion support for BOLT 12 offers#3833

Open
shaavan wants to merge 10 commits intolightningdevkit:mainfrom
shaavan:currency
Open

Add currency conversion support for BOLT 12 offers#3833
shaavan wants to merge 10 commits intolightningdevkit:mainfrom
shaavan:currency

Conversation

@shaavan
Copy link
Copy Markdown
Member

@shaavan shaavan commented Jun 7, 2025

This PR adds support for currency-denominated Offers in LDK’s BOLT 12 offer-handling flow.

Previously, Offers could only specify their amount in millisatoshis. However, BOLT 12 allows Offers to be denominated in other currencies such as fiat. Supporting this requires converting those currency amounts into millisatoshis at runtime when validating payments and constructing invoices.

Because exchange rates are external, time-dependent, and application-specific, LDK cannot perform these conversions itself. Instead, this PR introduces a CurrencyConversion trait which allows applications to provide their own logic for resolving currency-denominated amounts into millisatoshis. LDK remains exchange-rate agnostic and simply invokes this trait whenever a currency amount must be resolved.

To make this conversion logic available throughout the BOLT 12 flow, OffersMessageFlow is parameterized over a CurrencyConversion implementation and the abstraction is threaded through the offer handling pipeline.

With this in place:

  • OfferBuilder can now create Offers whose amounts are denominated in currencies instead of millisatoshis

InvoiceRequest handling can resolve Offer amounts when validating requests

InvoiceBuilder enforces that the final invoice amount satisfies the Offer’s requirements after resolving any currency denomination

Currency validation is intentionally deferred until invoice construction when necessary, keeping earlier stages focused on structural validation while ensuring the final payable amount is correct.

Tests are added to cover the complete Offer → InvoiceRequest → Invoice flow when the original Offer amount is specified in a currency.

@ldk-reviews-bot
Copy link
Copy Markdown

ldk-reviews-bot commented Jun 7, 2025

👋 Thanks for assigning @jkczyz as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@shaavan
Copy link
Copy Markdown
Member Author

shaavan commented Jun 7, 2025

cc @jkczyz

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 1st Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@joostjager
Copy link
Copy Markdown
Contributor

Is this proposed change a response to a request from a specific user/users?

@shaavan
Copy link
Copy Markdown
Member Author

shaavan commented Jun 11, 2025

Hi @joostjager!

This PR is actually a continuation of the original thread that led to the OffersMessageFlow: link to thread.

The motivation behind it was to provide users with the ability to handle InvoiceRequests asynchronously—just like we already allow for Bolt12Invoices. However, adding more events into the middle of the ChannelManager flow felt suboptimal.

So, as a first step, we worked on refactoring most of the Offers-related code out of ChannelManager into the new OffersMessageFlow (#3639). Now that the refactor is complete, this PR picks up the original goal again: to let users asynchronously handle both InvoiceRequests and Invoices. This not only gives them more flexibility in analyzing these Offer messages, but also opens the door for creating custom interfaces—for example, to support Offers in different currency denominations.

Hope that gives a clear picture of the intent behind this! Let me know if you have any thoughts or suggestions—would love to hear them. Thanks a lot!

@jkczyz
Copy link
Copy Markdown
Contributor

jkczyz commented Jun 11, 2025

Another use case is Fedimint, where they'll want to include their own payment hash in the Bolt12Invoice.

@valentinewallace
Copy link
Copy Markdown
Contributor

Another use case is Fedimint, where they'll want to include their own payment hash in the Bolt12Invoice.

Does Fedimint plan to use the OffersMessageFlow without a ChannelManager?

@jkczyz
Copy link
Copy Markdown
Contributor

jkczyz commented Jun 11, 2025

Does Fedimint plan to use the OffersMessageFlow without a ChannelManager?

I believe with one.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 2nd Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 3rd Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 4th Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 5th Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 6th Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 7th Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 8th Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 9th Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 10th Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 11th Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@jkczyz jkczyz removed the request for review from joostjager July 2, 2025 13:38
@jkczyz
Copy link
Copy Markdown
Contributor

jkczyz commented Jul 2, 2025

Removing @joostjager for now to stop bot spam. @shaavan and I have been working through some variations of this approach.

Copy link
Copy Markdown
Contributor

@vincenzopalazzo vincenzopalazzo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concept ACK for me

I was just looking around to sync with this Offer Flow

@shaavan shaavan changed the title Introduce Event Model for Offers Flow Introduce Synchronous Currency Conversion Support in Offers Aug 2, 2025
@codecov
Copy link
Copy Markdown

codecov bot commented Aug 2, 2025

Codecov Report

❌ Patch coverage is 90.32258% with 33 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.37%. Comparing base (6749bc6) to head (a4742bd).
⚠️ Report is 85 commits behind head on main.

Files with missing lines Patch % Lines
lightning/src/offers/flow.rs 79.01% 16 Missing and 1 partial ⚠️
lightning/src/offers/offer.rs 61.53% 5 Missing ⚠️
lightning/src/offers/invoice.rs 94.36% 3 Missing and 1 partial ⚠️
lightning/src/offers/invoice_request.rs 94.44% 3 Missing ⚠️
lightning/src/ln/channelmanager.rs 92.30% 1 Missing and 1 partial ⚠️
lightning/src/ln/offers_tests.rs 97.70% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3833      +/-   ##
==========================================
+ Coverage   89.34%   89.37%   +0.02%     
==========================================
  Files         180      180              
  Lines      138480   140045    +1565     
  Branches   138480   140045    +1565     
==========================================
+ Hits       123730   125164    +1434     
- Misses      12129    12295     +166     
+ Partials     2621     2586      -35     
Flag Coverage Δ
fuzzing 35.13% <4.10%> (-0.84%) ⬇️
tests 88.71% <90.32%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 8th Reminder

Hey @jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 9th Reminder

Hey @jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 10th Reminder

Hey @jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 11th Reminder

Hey @jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@shaavan
Copy link
Copy Markdown
Member Author

shaavan commented Apr 3, 2026

Updated .13 → .14

Changes:

  • Moved CurrencyConversion ownership out of OffersMessageFlow and into ChannelManager, so conversion state is carried at the manager level and passed into request, invoice, and payment handling directly.
  • Stopped eagerly converting fiat-denominated offer amounts into InvoiceRequest.amount_msats; omitted request amounts now stay omitted and are resolved later via InvoiceRequest::amount_msats(converter).
  • Added payer-side invoice amount verification against the pending invoice request, so the received BOLT12 invoice is checked using the local converter instead of relying on earlier request-time normalization.
  • Aligned async and static invoice paths with the new model by retaining the original InvoiceRequest in pending outbound state and binding static-invoice payment secrets to the resolved amount.

@shaavan
Copy link
Copy Markdown
Member Author

shaavan commented Apr 3, 2026

Rebased .14 → .15

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 12th Reminder

Hey @jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 13th Reminder

Hey @jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

shaavan and others added 6 commits April 6, 2026 14:14
InvoiceRequest::amount_msats previously mixed two meanings: the
explicit request field and the payable amount inferred from the offer.
That made call sites harder to reason about and obscured whether code
was validating the request itself or the amount that should ultimately
be paid.

Make amount_msats return only the amount set directly on the request
and remove the redundant has_amount_msats helper. Introduce
payable_amount_msats for callers that need the effective amount derived
from the request or offer, preserving explicit error reporting for
missing, unsupported, or overflowing payable amounts.

Co-Authored-By: OpenAI Codex <codex@openai.com>
Add a `CurrencyConversion` trait for resolving currency-denominated amounts
into millisatoshis.

LDK cannot supply exchange rates itself, so applications provide this
conversion logic as the foundation for fiat-denominated offer support.

Co-Authored-By: OpenAI Codex <codex@openai.com>
Add an internal MsatsRange type that carries a nominal millisatoshi
amount together with its tolerated conversion range.

This creates a single canonical result for resolving currency-denominated
offer amounts without forcing callers to immediately collapse back to a
plain u64. Keeping the nominal amount and tolerance together makes the
later quantity-scaling and tolerant-comparison logic easier to express in
one place.

The new Amount::resolve_msats helper is the first consumer of this
abstraction. It keeps the current behavior for bitcoin amounts while
capturing the tolerance returned by CurrencyConversion for fiat amounts.

Co-Authored-By: OpenAI Codex <codex@openai.com>
Add focused coverage for the new MsatsRange abstraction.

The feature commit introduces a canonical range representation for resolved
currency amounts, so this commit exercises the two key behaviors that later
callers will rely on: deriving tolerated bounds from a conversion snapshot
and scaling that range by quantity.

It also moves TestCurrencyConversion into shared test utilities so later
currency-conversion tests can reuse the same fixed USD rate and tolerance.

Co-Authored-By: OpenAI Codex <codex@openai.com>
Store the CurrencyConversion implementation on ChannelManager and
thread it through the ChannelManager constructor and read args.

This keeps the conversion dependency with the higher-level payment state
machine that owns offer payment state and persistence, while updating the
background processor, block-sync setup, tests, and fuzz harnesses to
construct ChannelManager with an explicit conversion implementation.

Co-Authored-By: OpenAI Codex <codex@openai.com>
Allow OfferBuilder to accept currency-denominated amounts when the
caller provides a CurrencyConversion implementation.

Validate configured amounts in the builder setters instead of deferring
that work to build(). That lets unsupported or invalid fiat amounts fail
at the point they are provided and keeps build() focused on assembling
and signing already-validated offer state.

Co-Authored-By: OpenAI Codex <codex@openai.com>
@ldk-reviews-bot
Copy link
Copy Markdown

🔔 14th Reminder

Hey @jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

shaavan and others added 4 commits April 8, 2026 19:26
Keep currency-dependent amount validation out of invoice request
construction.

Requests that omit amount_msats now keep the amount omitted on the
wire. Request construction and parsing still enforce every amount
check that is knowable locally, including missing-amount, quantity,
bitcoin-denominated minimums, and max-value checks, while deferring
minimum validation that requires currency conversion.

This also drops the extra CurrencyConversion plumbing and related API
changes from this commit so it stays focused on the request-side
behavior change. Follow-up commits resolve and verify deferred amounts
when handling invoices.

Co-Authored-By: OpenAI Codex <codex@openai.com>
Extend invoice request parsing tests for currency-denominated offers to
cover explicit serialized msat amounts.

Verify that well-formed explicit amounts still parse and out-of-range values
are rejected.

Co-Authored-By: OpenAI Codex <codex@openai.com>
Use CurrencyConversion when turning an authenticated InvoiceRequest into
a BOLT12 invoice and when validating the returned invoice on the payer.

This keeps invoice_request.amount_msats omitted on the wire for
currency-denominated offers while still letting the payee quote a
concrete millisatoshi amount in the invoice. The same payable-amount
logic is then reused to validate the authenticated invoice before it is
recorded or paid.

Payer-side amount validation failures are treated as local terminal
errors. Invalid quoted amounts and unsupported local currency conversion
now abandon the pending payment immediately instead of surfacing later as
an invoice-request timeout, and they do not send InvoiceError back to the
payee.

Async and static invoice paths resolve offer amounts through the same
conversion-aware builder so resolved amounts, payment secrets, and
payment handling stay in sync.

Co-Authored-By: OpenAI Codex <codex@openai.com>
Add tests for the deferred amount-validation model used by offer-backed
BOLT12 invoices.

The parsing coverage now exercises currency-denominated offers whose
invoice requests omit amount_msats, confirming that the authenticated
invoice may quote a concrete msat amount while the request remains
omitted on the wire.

The functional tests also cover the payer-side failure paths after
invoice verification. An authenticated invoice with an out-of-range
quoted amount and an authenticated invoice that requires unsupported
local currency conversion both fail immediately with PaymentFailed and do
not remain tracked as recent pending payments.

Co-Authored-By: OpenAI Codex <codex@openai.com>
@shaavan
Copy link
Copy Markdown
Member Author

shaavan commented Apr 9, 2026

Updated .15 → .16

Changes:

  • Reworked invoice-request handling around a deferred-validation model: fiat offers can omit invoice_request.amount_msats without threading CurrencyConversion through InvoiceRequestBuilder, and request-side validation now keeps only the checks that are possible without conversion.
  • Introduced range-based payable amount handling for offer-backed invoice validation. Payees resolve omitted fiat amounts when constructing the invoice, and payers now validate the returned invoice against the locally expected MsatsRange instead of requiring an exact msat match.
  • Payer-side validation failures are now terminal local failures. Invalid quoted amounts and unsupported local currency conversion immediately abandon the pending payment and emit failure instead of leaving the payment stuck awaiting timeout.
  • Removed the need to persist the original InvoiceRequest solely for amount revalidation by using the authenticated request embedded in the verified invoice, and updated coverage for omitted-amount fiat parsing plus the invalid-amount and unsupported-currency failure paths.

@shaavan
Copy link
Copy Markdown
Member Author

shaavan commented Apr 9, 2026

cc @jkczyz

Comment on lines +1191 to +1194
pub fn maximum_msats(&self) -> u64 {
let amount_msats = self.amount_msats as u128;
let maximum_msats = (amount_msats * (100 + self.tolerance as u128)).div_ceil(100);
u64::try_from(maximum_msats).expect("tolerance range cannot overflow")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: maximum_msats() can panic when amount_msats * (100 + tolerance) exceeds u64::MAX * 100. This is reachable when checked_mul (line 1206) produces a large but valid u64 value combined with a non-zero tolerance.

For example: amount_msats = 18_200_000_000_000_000_000 (fits in u64), tolerance = 5maximum_msats = amount * 105 / 100 ≈ 19.1e18 > u64::MAX → panic.

This path is hit in production from channelmanager.rs:5803 via requested_amount.contains(invoice.amount_msats()), where the MsatsRange comes from payable_amount()checked_mul(quantity). An attacker crafting a currency-denominated offer with a large amount could trigger this panic on the payer's node if the payer's exchange rate × offer amount × quantity is close to u64::MAX.

Consider using saturating arithmetic:

Suggested change
pub fn maximum_msats(&self) -> u64 {
let amount_msats = self.amount_msats as u128;
let maximum_msats = (amount_msats * (100 + self.tolerance as u128)).div_ceil(100);
u64::try_from(maximum_msats).expect("tolerance range cannot overflow")
pub fn maximum_msats(&self) -> u64 {
let amount_msats = self.amount_msats as u128;
let maximum_msats = (amount_msats * (100 + self.tolerance as u128)).div_ceil(100);
u64::try_from(maximum_msats).unwrap_or(u64::MAX)
}

None => builder.amount_msats(1000).unwrap(),
Some(Amount::Bitcoin { amount_msats }) => builder.amount_msats(amount_msats + 1)?,
Some(Amount::Currency { .. }) => return Err(Bolt12SemanticError::UnsupportedCurrency),
Some(amount) => builder.amount_msats(amount.into_msats(&conversion)?)?,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (won't compile): Amount has no into_msats method. The only conversion method is resolve_msats, which is pub(crate) and not accessible from the fuzz crate.

The old code matched on Amount::Bitcoin and Amount::Currency variants separately. The replacement needs a public conversion path. Possible fix: add a pub fn into_msats on Amount, or match on variants here as before:

Suggested change
Some(amount) => builder.amount_msats(amount.into_msats(&conversion)?)?,
Some(Amount::Bitcoin { amount_msats }) => builder.amount_msats(amount_msats + 1)?,
Some(Amount::Currency { .. }) => return Err(Bolt12SemanticError::UnsupportedCurrency),

Comment on lines +16960 to +16965
Err(Bolt12PaymentError::InvalidAmount)
| Err(Bolt12PaymentError::UnsupportedCurrency) => {
self.abandon_payment_with_reason(
$payment_id, PaymentFailureReason::UnexpectedError
);
return None;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Double payment abandonment. When send_payment_for_verified_bolt12_invoice (line 5763-5768) encounters InvalidAmount or UnsupportedCurrency from check_bolt12_invoice_amount, it already calls abandon_payment_with_reason(payment_id, PaymentFailureReason::UnexpectedError) at line 5766 before returning the error. Then this macro arm calls abandon_payment_with_reason again.

The second call is effectively a no-op (the payment was already removed), but it's redundant and confusing. Either remove the abandon_payment call from send_payment_for_verified_bolt12_invoice (lines 5765-5767) and let the macro handle it, or don't match these error variants here (since they're already handled before the error propagates).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants